Hash expressions do not escape HTML
What you'll see
Nothing throws. The server returns HTTP 200 and the page looks almost right.
- A stored product or customer name containing
<b>renders in bold instead of showing the tag. - A stored value containing a quote breaks the element it sits in: an attribute terminates early and the remainder of the tag leaks into the page as visible text.
- One table row renders with mangled columns, and the underlying record turns out to contain an unclosed tag.
- A security review asks where output encoding happens, and the answer is nowhere.
The benign version of this — a stray tag from a pasted value — is the same defect as the malicious version. Only the stored content differs.
What's actually happening
Docly does not escape template output. A #name# expression and a write() call both emit the value verbatim. There is no context-aware encoding, no escaping that is on by default, and no warning.
This is the reverse of what most template engines have trained developers to expect. Razor, JSX and Angular interpolation all escape by default and require an explicit opt-out to emit raw HTML. Hash templates require an explicit opt-in to be safe, so the insecure spelling is also the shortest one.
A throwaway page settles it in a single request:
#{
docly.setMime("text/plain");
write("write(): " + "<b>x</b> \" ' &" + "\n");
write("htmlEncode(): " + docly.htmlEncode("<b>x</b> \" ' &") + "\n");
write("attrEncode(): " + docly.htmlAttributeEncode("<b>x</b> \" ' &") + "\n");
}#
expr: #'<b>x</b> " &'# Output:
write(): <b>x</b> " ' &
htmlEncode(): <b>x</b> " ' &
attrEncode(): <b>x</b> " ' &
expr: <b>x</b> " & Two things to read out of that. The expression form escapes nothing at all. And the two encoders are not interchangeable: htmlAttributeEncode() escapes <, ", ' and & but deliberately leaves > alone, because a bare > cannot terminate a quoted attribute value. It is correct inside an attribute and wrong for element content.
The same gap exists one layer out. A web component that builds a table row with a template literal and assigns it to innerHTML is doing exactly what a hash expression does, in the browser instead of on the server. Field validation does not close it either: a value can be required, correctly typed and a perfectly valid string while still containing <img src=x onerror=…>.
Where this bites hardest is a low-privilege user writing a field that a high-privilege user later views. The script then runs in the administrator's session, which turns a content field into a privilege-escalation path.
What to do
Encode at the point of output, and choose the function by context:
- Element content —
docly.htmlEncode(value) - Attribute value —
docly.htmlAttributeEncode(value), and always keep the attribute quoted - URL or query component —
encodeURIComponent(value). This solves a different problem and is not a substitute for either of the above - Inside a
<script>block — do not interpolate into JavaScript source at all. Emit the value as JSON withJSON.stringify()and read it from there
Do not rely on validating input instead. Validation runs at one entry point. Output happens in every template that ever displays the field, including the ones written next year by someone else. Escaping at output is the only control that stays correct as the codebase grows.
Client-side components need the same discipline. Assign textContent rather than innerHTML wherever the value is plain text, or keep one helper beside the component:
htmlEncode(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
} Reviewing a template you did not write: list every place a stored field reaches the page and check each one for an encode call. A field that renders correctly today proves nothing — it proves only that nobody has stored markup in it yet. Be especially suspicious when one component in a codebase has an encoder and its siblings do not; that is the usual shape of this defect.
Add a Content-Security-Policy. It does not replace encoding, but it limits what a missed spot can do. See Security Headers in the headers.json file.
Confirm any of this in your own workspace with a scratch hash file, and delete the file when you are done.